干货 | 拆解一个 Elasticsearch Nested 类型复杂查询问题
1、线上实战问题
前置说明:本文是线上环境的实战问题拆解,涉及复杂 DSL,看着会很长,但强烈建议您耐心读完。
问题描述:
有个复杂的场景涉及到按照求和后过滤,user_id是用户编号,gender是性别,time_label是时间标签,时间标签是nested结构,intent_order_count是意向订单数量,time是对应时间。
现在要筛选出在20210510~20210610,意向订单数总和为26的男性用户,请问应该怎么写dsl语句?
感觉这个场景很复杂,涉及到array判断后求和,然后求和结果做筛选条件。
请帮忙看看有什么好的dsl语句,或者改变现有mapping结构。
这个是mapping结构 如下:
PUT index_personal
{
"mappings": {
"properties": {
"time_label": {
"type": "nested",
"properties": {
"intent_order_count": {
"type": "long"
},
"time": {
"type": "long"
}
}
},
"user_id": {
"type": "keyword"
},
"gender": {
"type": "keyword"
}
}
}
}
下面是我构造的数据:
PUT index_personal/_doc/1
{
"user_id": "1",
"gender": "male",
"time_label": [
{
"time": 20210601,
"intent_order_count": 3
},
{
"time": 20210602,
"intent_order_count": 2
},
{
"time": 20210605,
"intent_order_count": 20
},
{
"time": 20210606,
"intent_order_count": 1
},
{
"time": 20210611,
"intent_order_count": 15
}
]
}
PUT index_personal/_doc/2
{
"user_id": "2",
"gender": "female"
}
PUT index_personal/_doc/3
{
"user_id": "3",
"gender": "male",
"time_label": [
{
"time": 20210102,
"intent_order_count": 12
},
{
"time": 20210202,
"intent_order_count": 33
}
]
}
问题扩展解释:
1、"intent_order_count"代表:是订单数,不过都可以抽象成这个用户某个时间买了几个。
比如第三条数据,表示用户编号为 3 的用户,是男性用户,曾经在 20210102 时有12个意向订单(跟订单一个意思),在 20210202 有 33 个意向订单,
2、每个用户除了性别还有很多属性,篇幅受限,没有列出。
问题来源:https://t.zsxq.com/FmEeaIY
2、数据建模探讨
2.1 原问题 Nested 模型
原有数据,以 Nested 建模,存储结构如下:
user_id | gender | time_label {time:intent_order_count} |
---|---|---|
1 | male | [ {20210601:3} {20210602:2}{20210605:20}{20210606:1}{20210611:15}] |
2 | female | |
3 | male | { 20210102:12}{20210202:33} |
以上表示并不严谨,仅是为了更直观的阐述问题。
2.2 宽表建模方案
拿到问题后,我的第一反应:建模可能有问题。
第一:time 存储的是日期,应该是日期类型:date。
第二:宽表拉平存储是不是更好?!也就是说:针对:“user_id” 的用户,一个时间数据,对应一个 document 文档。
原有的 nested 结构,改成如下的一条条的记录,也就是“宽表”,类似简化存储如下:
user_id | gender | time | intent_order_count |
---|---|---|---|
1 | male | 20210601 | 3 |
1 | male | 20210602 | 2 |
1 | male | 20210605 | 20 |
1 | male | 20210606 | 1 |
1 | male | 20210611 | 15 |
2 | female | ||
3 | male | 20210102 | 12 |
3 | male | 20210202 | 33 |
“宽表”是典型的以空间换时间的方案,我们肉眼看到的:对于 user_id=1 的 用户,user_id, gender 信息会存储 N 份(每多一次 time,就多存储一次)。
如前所述,每个用户除了性别还有很多属性,也就是属性非常多的话,会产生大量的冗余存储。
宽表方案优缺点如下:
优点:更利用用户理解,写入和更新非常方便且效率高。 缺点:存在大量冗余存储,耗费空间大。
针对“宽表”方案,问题提出者球友的反馈如下:
“这确实也是个思路。但是我的这个场景下,每个用户除了性别还有很多属性,这样会每天都会产生大量的冗余数据。
是否有办法将一个用户的时间信息聚集到一个文档下,然后也能够查询,对查询效率要求不高。”
所以,还得从 Nested 建模角度基础上,考虑如何实现查询?
2.3 Nested 建模方案
原有建模问题无大碍,只需将:time 字段由 long 类型改为 date 类型,其他保持不变。
# 新的 Mapping 结构(微调)
PUT index_personal_02
{
"mappings": {
"properties": {
"time_label": {
"type": "nested",
"properties": {
"intent_order_count": {
"type": "long"
},
"time": {
"type": "date"
}
}
},
"user_id": {
"type": "keyword"
},
"gender": {
"type": "keyword"
}
}
}
}
# 还是原来的构造数据,改成bulk,占据行数更少
PUT index_personal_02/_bulk
{"index":{"_id":1}}
{"user_id":"1","gender":"male","time_label":[{"time":20210601,"intent_order_count":3},{"time":20210602,"intent_order_count":2},{"time":20210605,"intent_order_count":20},{"time":20210606,"intent_order_count":1},{"time":20210611,"intent_order_count":15}]}
{"index":{"_id":2}}
{"user_id":"2","gender":"female"}
{"index":{"_id":3}}
{"user_id":"3","gender":"male","time_label":[{"time":20210102,"intent_order_count":12},{"time":20210202,"intent_order_count":33}]}
良好的数据建模就好比盖大楼的地基,地基自然是越稳、越实、越牢靠越好!
3、查询方案拆解
3.1 分步骤拆解用户查询需求
问题拆解成如下几个部分:
3.1.1 筛选出在20210510~20210610
DSL 写法如下:
{
"nested": {
"path": "time_label",
"query": {
"bool": {
"must": [
{
"range": {
"time_label.time": {
"gte": 20210510,
"lte": 20210601
}
}
}
]
}
}
}
}
正常写 Query 不会涉及 Nested,只有涉及 Nested 数据类型,才必须在检索的前半部分加上 Nested 声明,其目的无非告诉 Elasticsearch 后台,这是针对 Nested 类型的检索。
Path 指定的Nested 最外层,在本文指定的是:time_label。
3.1.2 意向订单数总和为26的男性用户
铭毅拆解:
关于男性用户,这里可以基于性别检索做过滤。
DSL 写法如下:
{
"term": {
"gender": {
"value": "male"
}
}
}
关于意向订单:对于 user_id = 1 的用户,意向订单总数就等于 3 + 2 + 20 + 1 + 15 = 41。
要实现类似的求和,得需要借助 sum Metric 指标聚合实现。
sum Metric 聚合的前提是:针对某一特定用户形成一个结果,所以其外层是基于用户维度(本文使用:user_id)层面的terms聚合。
为了显示出除了聚合结果之外的其他属性列,需要借助 top_hits 的 _source 中的 include 实现。
DSL 写法大致如下:
"aggs": {
"user_id_aggs": {
"terms": {
"field": "user_id"
},
"aggs": {
"top_sales_hits": {
"top_hits": {
"_source": {
"includes": [
"user_id",
"gender"
]
}
}
},
"resellers": {
"nested": {
"path": "time_label"
},
"aggs": {
"sum_count": {
"sum": {
"field": "time_label.intent_order_count"
}
}
}
}
如上:
最外层 terms 聚合:是基于 user_id 的分桶聚合,每个 user_id 的结果聚成一桶。
内层的聚合包含两个,两个是平级的。
其一:top_hits 指标聚合,用于显示聚合结果之外的字段。
其二:sum 指标聚合,用于对“time_label.intent_order_count”统计结果求和。
除了上面的两层聚合,又涉及总和结果和 26 进行比较,所以要基于聚合的聚合,也就是子聚合的实现。
DSL 写法如下:
"count_bucket_filter": {
"bucket_selector": {
"buckets_path": {
"totalcount": "resellers.sum_count"
},
"script": "params.totalcount >= 26"
}
}
文中给的实际例子没有满足 26 的文档,所以,这里为了直观显示结果,使用了 >= 26 实现。
3.1.3 应该怎么写dsl语句?
铭毅拆解:
基于上面几个步骤整合到一起,即可实现。
查询 DSL ——即用户最终期望。查询 DSL 就类似“图纸”、“导航”或“路径”,给出了达到给定目的的可行性路径,后面无非就是:java 或者 Python 代码的“堆砌”实现。
3.2 最终 DSL
POST index_personal_02/_search
{
"size": 0,
"query": {
"bool": {
"must": [
{
"nested": {
"path": "time_label",
"query": {
"bool": {
"must": [
{
"range": {
"time_label.time": {
"gte": 20210510,
"lte": 20210601
}
}
}
]
}
}
}
},
{
"term": {
"gender": {
"value": "male"
}
}
}
]
}
},
"aggs": {
"user_id_aggs": {
"terms": {
"field": "user_id"
},
"aggs": {
"top_sales_hits": {
"top_hits": {
"_source": {
"includes": [
"user_id",
"gender"
]
}
}
},
"resellers": {
"nested": {
"path": "time_label"
},
"aggs": {
"sum_count": {
"sum": {
"field": "time_label.intent_order_count"
}
}
}
},
"count_bucket_filter": {
"bucket_selector": {
"buckets_path": {
"totalcount": "resellers.sum_count"
},
"script": "params.totalcount >= 26"
}
}
}
}
}
}
要强调的点是:
第一:涉及 Nested 的 query 检索 以及 aggs 聚合,都需要明确指定 Nested Path。
第二:复杂检索和聚合出错多数是:子聚合的位置放的不对、后括号和前括弧不匹配等,需要多在 Kibana 测试验证。
第三:Kibana 的一键 DSL 美化快捷键:“ctrl + i” 要掌握和灵活使用。
相信经过上面的拆解,这个相对“复杂”的 DSL 会变得非但不那么“复杂”,反而非常容易读懂。
3.3 查询后结果
"aggregations" : {
"user_id_aggs" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{
"key" : "1",
"doc_count" : 1,
"top_sales_hits" : {
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.4418328,
"hits" : [
{
"_index" : "index_personal_02",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.4418328,
"_source" : {
"gender" : "male",
"user_id" : "1"
}
}
]
}
},
"resellers" : {
"doc_count" : 5,
"sum_count" : {
"value" : 41.0
}
}
}
]
}
}
由于检索 size = 0,所以,只返回了聚合结果,没有返回检索结果。
由于二层聚合设置了 top_hits,所以返回结果里除了sum_count的聚合结果,还包含的其下钻数据字段:“gender”、“user_id” 信息,如果实际业务还有更多需要召回字段,可以一并 include 包含后返回即可。
4、有没有更简单的方案?
第 3 小节的实现是基于聚合,但实际文档是 Nested 类型的,基于 userr_id 聚合显得非常的多余。
这里自然想到,用检索能否实现?
如果简单检索不行,那么脚本检索呢?
4.1 扩展方案 1:脚本检索实战
搞一把试试。
GET index_personal_02/_search
{
"query": {
"bool": {
"must": [
{
"nested": {
"path": "time_label",
"query": {
"bool": {
"must": [
{
"range": {
"time_label.time": {
"gte": 20210510,
"lte": 20210613
}
}
},
{
"script": {
"script": """
int sum = 0;
for (obj in doc['time_label.intent_order_count']) {
sum += obj;
}
sum >= 10;"""
}
}
]
}
}
}
},
{
"term": {
"gender": {
"value": "male"
}
}
}
]
}
}
}
如上逻辑看似非常严谨的脚本,实际是行不通的。
sum += obj; 本质上只求了一个值。
Elastic 官方工程师给出了详细的解释:“无法在查询时访问脚本中所有嵌套对象的值。脚本查询一次仅适用于一个嵌套对象。”
详细讨论参见:
https://stackoverflow.com/questions/64140179/elasticsearch-sum-up-nested-object-field
https://discuss.elastic.co/t/help-for-painless-iterate-nested-fields/162394
结论:脚本检索不适用 Nested 嵌套对象求和。
官方推荐用 Ingest pipeline 预处理方式实现,那就再搞一把。
4.2 扩展方案 2:Ingest pipeline 方式实战
4.2.1 步骤 1——设置求和的 pipeline。
sum_pipeline 用途:将 nested 嵌套的 intent_order_count 字段进行求和。
# 设定pipeline,统计计数总和
PUT _ingest/pipeline/sum_pipeline
{
"processors": [
{
"script": {
"source": """
ctx.sum_count = ctx.time_label.stream()
.mapToInt(thing -> thing.intent_order_count)
.sum()
"""
}
}
]
}
4.2.2 步骤 2——结合 pipeline 更新数据
注意一下:nested 添加数据需要借助 script 实现,不能直接指定 id 插入。
若指定 id 插入数据会覆盖掉之前的数据。
# 新插入数据
POST index_personal_02/_update_by_query?pipeline=sum_pipeline
{
"query":{
"term": {
"user_id": {
"value": "1"
}
}
},
"script": {
"source": "ctx._source.time_label.add(params.newlabel)",
"params": {
"newlabel": {
"time": 20210702,
"intent_order_count": 88
}
}
}
}
4.2.3 步骤 3——结合文章开头要求进行检索
借助 pipeline 新增的字段 sum_count 可以检索条件之一。
# 检索结果
GET index_personal_02/_search
{
"query": {
"bool": {
"must": [
{
"nested": {
"path": "time_label",
"query": {
"bool": {
"must": [
{
"range": {
"time_label.time": {
"gte": 20210510,
"lte": 20210601
}
}
}
]
}
}
}
},
{
"term": {
"gender": {
"value": "male"
}
}
},
{
"range": {
"sum_count": {
"gte": 26
}
}
}
]
}
}
}
Ingest pipeline 方案小结:
通过预处理管道新增字段,以空间换时间。 新增的字段作为检索的条件之一,不再需要聚合。
5、小结
分解是计算思维的核心思想之一,“大事化小,逐个击破”。本文的拆解思路也是基于分解的思想一步步拆解。
本文针对线上问题,抛转引玉,给出了方案拆解和完整的步骤实现。
共探索出两种可行的方案:
方案一:聚合实现。
方案一本质:两重嵌套聚合(terms分桶 + 分桶内 sum 指标聚合)+ 子聚合(基于聚合的聚合 bucket_selector)实现。
方案二:预处理管道 pipeline 实现。
方案二本质:新增求和字段,以空间换时间。
实战环境类似本文问题,铭毅推荐使用方案二。
细节问题待进一步结合线上需求进行扩展修改 DSL。
欢迎就问题及方案进行留言,说一下您的思考和思路反馈。
https://discuss.elastic.co/t/script-processor-ingest-pipelines-on-nested-fields/172092/2